iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png

Reader

在介紹 IO Functor時,IO<A>的型別便是由函數建構,本質上是一個無參數的函數,它的型別簽名(Type Signature)為 () => A。而IO模組中map函數的底層工作便是脫去thunk函數這一層,讓它們的回傳值直接進行合成,最後再將得到的結果包回這一層IO thunk。函數本身也可以視為型別建構子或型別容器,而今天要討論的Reader型別建構子Reader<R, A>和IO型別建構子IO<A>類似,它的型別簽名是 (r: R) => A,它和IO不同在於它是一個固定輸入型別R的函數。

Reader這個型別容器通常應用的場景是依賴注入(Dependency Injection),R 代表「環境」(Environment)或「依賴」(Dependency)的型別,我們需要從外部注入的設定或依賴內容物件。

A 是「函數輸出的結果」(Result)型別,這是函數根據輸入內容 R 後計算出的值。

它和IO一樣,也是一個「尚未執行的計算」,口語的意思是:「只要你給我一個 R 型別的依賴輸入,我會回傳一個 A 類型的輸出結果」,也就是函數本身的精神。

Reader本身就只是一個函數所定義的型別建構子,並沒有什麼特別,只是以常見的應用場景命名為Reader,因此我們直接以下面的使用情境做說明。假設我們有一個函數,需要根據使用者的 ID 從資料庫中取得使用者名稱,然後生成一句問候語,初看這個情境,我們可能這樣進行我們的流程。

// 直接導入 `db` 實例
import { db } from './my-database';
import { config } from './config';

const getUserName = (userId: number): string => {
  // 直接呼叫 db,這是一個「副作用(Side effect)」
  const user = db.getUserById(userId);
  return user.name;
};

const getAppName = (config: {appName: string}): string => {
    return config.appName
}

const createGreeting = (userId: number): string => {
  const name = getUserName(userId);
  const appName = getAppName(config)
  return `Hello, ${name}!`;
};

由於我們的函數getUserName不是一個純函數,連帶的createGreeting也不是一個純函數。現在改變我們的程式設計方式,先定義一個非常簡單Dependencies的介面。

interface Dependencies {
  db: {
    getUserById: (id: number) => { name: string };
  };
  config: {
    appName: string;
  };
}

這裏我們的定義Dependencies介面裏有db和config兩個屬性,而db也是物件,裏面有定義getUserById這個函數的型別簽名;而config這個屬性裏有appName這個字串屬性。接下來改寫我們getUserName函數的型別和實作。

import { Reader, ask, map } from 'fp-ts/Reader';
import { pipe } from 'fp-ts/function';
const prop = (p: string) => (obj: Record<string, any>) => obj[p];
type GetUserName = (userId: number) => Reader<Dependencies, string>;
const getUserName: GetUserName =
  (userId) =>
  ({ db: { getUserById } }) =>
    pipe(userId, getUserById, prop('name')); // 在這裡,我們從注入的 `deps` 輸入中獲取 db

GetUserName的型別簽名說,輸入一個number,輸出是Reader<Dependencies, string>,其實就是型別簽名為(deps: Dependencies) => string的函數。getUserName的實作部分使用柯里化(currying)的格式,輸出(deps)的部分,由於只有使用db屬性,我們解構了deps為{ db: { getUserById } },輸出的部分,我們用了FP的pipe格式,其中我們寫了一個prop函數可以取得物件的屬性值。注意,這邊的型別我們用了fp-ts中Reader的型別建構子,其實也可以不用,直接寫(deps: Dependencies) => string代替也可以。

fp-ts的Reader模組中提供了一個ask函數,它會回傳一個限制型別的identity函數(就是輸出和輸入一樣的函數),在「擒管」流程中使用,我們看看使用「ask」函數的寫法:

// 匯入省略
type GetUserName = (userId: number) => Reader<Dependencies, string>;
const getUserName: GetUserName =
  (userId) =>
pipe(
  ask<Dependencies>(), // Reader<Dependencies, Dependencies>
  map(({ db: { getUserById } }) => getUserById(userId)), // Reader<Dependencies, Record>
  map(prop('name'))
);

ask()會回傳一個Dependencies型別的資料成為管線(pipe)的下一個的輸入,排在管線下一個函數,一樣解構為{ db: { getUserById } },這一個函數的輸出與輸入都不是Reader型別,所以我們必須用Reader模組內的map函數來轉換,下一個函數prop('name')也是同樣的道理。

我們再改寫creatGreeting函數:

type CreateGreeting = (userId: number) => Reader<Dependencies, string>;
const createGreeting: CreateGreeting = (userId) =>
  pipe(
    getUserName(userId), // 輸出: Reader<Dependencies, string>
    map((name) => `Hello, ${name}!`) // 使用Reader的 map 對輸出、入進行轉換
  );

因為getUserName的輸出型別為Reader<Dependencies, string>,而(name) => Hello, ${name}!是string -> string,所以需要map,得到<Dependencies, string>的輸出。

最後,我們實作Dependencies型別的實例deps和主程式,並執行主程式

const realDependencies1: Dependencies = {
 db: {
   getUserById: (id) => {
     console.log(`[REAL DB] Querying user ${id}`);
     return { name: `Real User #${id}` };
   },
 },
 config: {
   appName: 'Ironman App',
 },
};

const program: Reader<Dependencies, string> = createGreeting(123);
// program是一個函數,當Dependencies型別實例尚未注入(輸入),函數尚末執行,因此是一個純函數。
// 主程式執行
const result = program(realDependencies1); // 注入依賴,執行程式
console.log(result); // 輸出: "Hello, Real User #123!"

以上是Reader簡單的示範,Reader模組也是Monad的模組,map、ap、flatMap(chain)的用法和其它的monad方式一樣,今天先簡單說明,明天會給一個更複雜的綜合示範。

State

State也是利用函數進行型別建構,它的HM型別簽名如下:

State<S, A> :: (s: S) => [A, S]

輸入是S型別的舊狀態(Previous state),輸出是一個元組(Tuple),第0個元素的型別為任何可能的型別A(在tuple的前面),代表此次資料流向的型別,它是State的第二個型別參數(在後面),容易混洧。當我們使用合成函數時,需要特別關注這個型別,是否滿足合成的條件(前一個函數的輸出型別等於後一個函數的輸入型別),而第1個元素則是一個S型別的新狀態(New state)。

假設我們有一個簡單的counterState

type CounterState = number;
//type State<CounterState, A> = (S: CounterState) => [A, CounterState]

接下來我們介紹State常用的幾個方法:

get: <S>() => State<S, S>

const getCount: State<CounterState, CounterState> = get();
console.log(getCount(5)); // [5, 5]

get是一個State建構函數,它不需要參數,會回傳一個State(其實就是一個函數),get扮演和Reader中ask類似的角色,它的輸出型別也是State<S, S>,通常在「接管」的開始使用。上面程式碼中的getCount是一個State,也就是一個函數,它輸出元組的兩個值會和輸入一致。

put: <S>(s: S) => State<S, void>

const setCount = (count: number): State<CounterState, void> => put(count);
console.log(setCount(42)(1000)); // [ undefined, 42 ]

put也是State的建構函數,它要一個S型別的參數,會回傳一個常數函數的State,也就是說不論你給這個State任何的輸入,它的輸出的新State都一樣,也就是當初建構時的值,而輸出元組的第0個元素會是undefined,所以它的輸出型別是State<CounterState, void>。所以上例中setCount(42)(1000)=setCount(42)(-99)

modify: <S>(f: (s: S) => S) => State<S, void>

const increment: State<CounterState, void> = modify((n) => n + 1);

const decrement: State<CounterState, void> = modify((n) => n - 1);

const add = (amount: number): State<CounterState, void> =>
  modify((n) => n + amount);

console.log(add(5)(3)); // [ undefined, 8]

modify函數的參數是一個S -> S的函數,回傳一個State<S, void>,所以上述increment、decrement和add三個方法回傳的State函數輸出的元組的第0個元素都是undefined。

最後要介紹的是State容器的取值函數,State的取值函數有二個,第一個是execute,用來取得元組的第1個元素,也就是取得State;另一個是evaluate,取得元組的第0個元素,也就是型別A的值。這兩個函數都是柯里化的函數,第一個參數是原來S的值,第二個參數是State<S, A>型別,下面是使用的範例,

console.log(execute(3)(increment)); // 4
console.log(evaluate(3)(increment)); // undefined

State模組提供這些函數主要目的是迴避實作的部分,主要希望能在抽象層面操作,我們不用記憶tuple中輸出和新State的位置;另一方面則藉此增加程式的易讀性。

State型別常使用於一些資料結構處理,以下是用State實作Stack的範例。

type Stack = number[];
const push = (value: number): State<Stack, void> =>
  modify((stack) => [...stack, value]);

// 取出最上面的值,並且Stack的元素也少了最上面的一個
const pop: State<Stack, number | undefined> = pipe(
  get<Stack>(), // State<Stack, Stack>
  // flatMap(Stack -> State<Stack, number | undefined> )
  flatMap(
    (stack) =>
      stack.length > 0
        ? pipe(
            put(dropRight(1)(stack)), // State<Stack, void>
            map(() => stack[stack.length - 1]) // State<Stack, number>
          )
        : of(undefined) // State<Stack, void>
  )
);

// 取出Stack中最上面的值,但是未Stack的狀態沒有改變
const peek: State<Stack, number | undefined> = pipe(
  get<Stack>(), // State<Stack, Stack>
  map((stack) => stack[stack.length - 1]) // map(Stack -> number | undefined) => State<Stack, Stack> -> State<Stack, number | undefined>
);

const updateStack = pipe(
  push(1), // State<Stack, void>
  flatMap(() => push(2)), // flatMap(void -> State<Stack, void>)
  flatMap(() => push(3)), // flatMap(void -> State<Stack, void>)
  flatMap(() => push(4)), // flatMap(void -> State<Stack, void>)
  flatMap(() => push(5)), // flatMap(void -> State<Stack, void>)
  flatMap(() => peek), // flatMap(void -> State<Stack, number | undefined>)
  flatMap(() => pop), // flatMap(void -> State<Stack, number | undefined>)
  flatMap(() => pop) // flatMap(void -> State<Stack, number | undefined>)
);

今日小結

我們可以函數中的=>符號當作一個運算符號,將原來函數輸入型別 => 輸出型別的型別簽名改成=> <輸入型別, 輸出型別>,如此看的話,=>也可以成為一個泛用的型別建構子,用柯里化的形式型別1 => 型別2 => 型別3的形式可以表示三個型別參數的型別建構子,依此類推。以Reader和State來說,它們和Either及EitherTask一樣有兩個型別參數,必須要提供一個型別參數後(也就是先固定一個型別參數),便可視為一個型別參數的Functor或Monad。使用map、flatMap、…這些函數時,必須注意型別簽名中只能有一個型別參數的型別是可以變動。

Reader和State都是以函數建構型別的重要例子,雖然各自有不同的應用場景,共通點是利用函數的特性保持了純函數的特性,也就是函數未執行,一切都只靜態未發生任何副作用(Side Effect),等到函數一旦執行,副作用便發生。為了區分程式純函數與非純函數,通常會將執行副作用的程式部分推至程式的最末端。

以上是今天對Reader和State的簡單介紹,明天會給一個更複雜的Reader綜合示範,今日的分享就到這邊告一段落,明天再見。


上一篇
Day 18. 自然轉換 - Natural Transformation
下一篇
Day 20. fp-ts綜合練習
系列文
數學老師學函數式程式設計 - 以fp-ts啟航20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言